파이썬 클린코드 | 4장 SOLID 원칙

파이썬 클린코드 | 4장 SOLID 원칙

날짜
Nov 22, 2023
태그
python
설명
파이썬스럽게 만드는 소프트웨어 원칙
 
SOLID 원칙을 파이썬스럽게 구현하면서 우수 사례들도 함께 살펴보자.
📌
- 소프트웨어 디자인에서의 SOLID - 단일 책임 원칙을 따르는 컴포넌트 디자인 - 개방-패쇄 원칙을 통한 유지보수 - 리스코프 치환 원칙을 준수한 객체지향 디자인에서의 적절한 클래스 계층 - 인터페이스의 분리와 의존성 역전을 활용한 설계
Single responsibility
단일 책임
Open-close
개방-패쇄의
Liscov’s subsitutution
리스코프 치환
Interface segregation
인터페이스 분리
Dependency inversion
의존성 역전
 

단일 책임 원칙(Single responsibility)

소프트웨어 컴포넌트(일반적으로 클래스)는 단 하나의 책임을 져야한다는 원칙이다. 유일한 책임은 구체적인 하나의 일을 담당한다는 것을 의미하며, 변경이 필요한 이유도 단 하나만 있어야 한다. 아마 다른 이유로 변경이 필요하다면 추상화가 잘못 되었거나 너무 많은 책임을 가지고 있다는 것이다.
SRP는 응집력과 밀접한 관련이 있다. 클래스에 있는 프로퍼티와 속성이 항상 메서드를 통해서 사용되도록 하는 것이다. 이들은 관련된 개념이기 때문에 동일한 추상화로 묶는 것이 가능하다.
 

너무 많은 책임을 가진 클래스

class SystemMonitor: def load_activity(self): """소스에서 처리할 이벤트 가져오기""" def identify_events(self): """가져온 데이터를 파싱하여 도메인 객체 이벤트로 변환""" def stream_event(self): """파싱한 이벤트를 외부 에이전트로 전송"""
이 클래스의 문제는 독립적인 동작을 하는 메서드를 하나의 인터페이스에 정의했다. 이러한 디자인은 유지보수를 어렵게 하여 클래스가 경직되고 융통성이 없고 오류가 발생하게 만든다.
각각의 동작은 나머지 부분과 독립적으로 수행할 수 있다. 외부 요소에 의한 영향을 최소화하고 보다 작고 응집력 있는 추상화를 해야한다.
 

책임 분산

너무 많은 책임을 가진 클래스
너무 많은 책임을 가진 클래스
여러 클래스에 책임을 분산한 모습
여러 클래스에 책임을 분산한 모습
쉽게 관리하기 위한 클래스 분리와 클래스가 단일 책임을 가지게 해야 한다.
각자의 책임을 가진 클래스를 만들고 이것이 인스턴스들과 교류하는 하나의 객체를 만든다. 각각의 클래스는 나머지와 독립적인 특정한 메스드를 캡슐화한 상태이므로 수정이 필요한 경우에도 나머지 객체에는 영향을 미치지 않게 된다. ActivityReader의 load()를 변경해도 AlertSystem은 그 사실조차 알지 못하며, 나머지 같은 계층의 클래스도 아무런 수정이 필요없다.
 
맨 처음부터 원칙을 준수하려고 할 필요는 없다. 나중에 보다 안정화된 버전으로 발전할 수 있도록, 확장 가능하고 수정가능한 디자인을 유지하기만 하면 된다.
 

개방-패쇄 원칙(Open-close)

모듈이 개방되어 있으면서도 패쇄되어야 한다는 원칙이다.
클래스를 만들때는 유지보수가 쉽도록 로직을 캡슐화하여 확장에는 개방되고 수정에는 패쇄되도록 해야 한다. 간단히 말해 확장이 가능하고, 새로운 요구사항이나 도메인 변화에 잘 적응하는 코드를 작성해야 한다. 즉, 새로운 문제가 발생할 경우 새로운 것을 추가만 할 뿐 기존 코드는 그대로 유지해야 한다.
새로운 코드를 추가하다가 기존 코드를 수정했다면 기존 로직이 잘못 디자인되었다는 것을 뜻한다.
 

개방-패쇄 원칙을 따르지 않을 경우 유지보수의 어려움

@dataclass class Event: raw_data: dict class UnknownEvent(Event): """데이터만으로 식별할 수 없는 이벤트""" class LoginEvent(Event): """로그인 사용자에 의한 이벤트""" class LogoutEvent(Event): """로그아웃 사용자에 의한 이벤트""" class SystemMonitor: """시스템에서 발생한 이벤트 분류""" def __init__(self, event_data): self.event_data = event_data def identify_event(self): if ( self.event_data["before"]["session"] == 0 and self.event_data["after"]["session"] == 1 ): return LoginEvent(self.event_data) elif ( self.event_data["before"]["session"] == 1 and self.event_data["after"]["session"] == 0 ): return LogoutEvent(self.event_data) return UnknownEvent(self.event_data) # None을 반환하지 않고 UnknownEvent를 반환하는 것은 다형성을 보장하기 위함이다(null 객체 패턴)
>>> 11 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}}) >>> 11.identify_event().__class__.__name__ "LoginEvent" >>> 12 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}}) >>> 12.identify_event().__class__.__name__ "LogoutEvent" >>> 13 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}}) >>> 13.identify_event().__class__.__name__ "UnknownEvent"
이 디자인에는 몇 가지 문제점이 있다.
  1. 이벤트 유형을 결정하는 로직이 단일 메서드(identify_event)에 집중되어 있다. - 지원하는 이벤트가 늘어날수록 메서드도 커지게 된다.
  1. 새로운 이벤트 시스템을 추가할때마다 메서드를 수정해야 한다 - elif 문은 계속 늘어나면서 가독성을 망친다.
 
  • 개방: 새로운 이벤트가 추가될때 기존 코드를 변경하지 않고 확장하여 새로운 유형의 이벤트를 지원하고 싶다.
  • 패쇄: 메서드를 변경하지 않고 새로운 유형의 이벤트를 추가하고 싶다.
 
문제점은 SystemMonitor 클래스가 분류하려는 구체 클래스와 직접 상호 작용한다는 점이다. 개방/폐쇄 원칙을 따르는 디자인을 달성하려면 추상화를 해야 한다.
 

확장성을 가진 이벤트 시스템으로 리팩터링

SystemMonitor 클래스를 추상적인 이벤트와 협력하도록 변경하고, 이벤트에 대응하는 개별 로직은 각 이벤트 클래스에 위임하는 것이다.
 
class Event: def __init__(self, raw_data): self.raw_data = raw_data @staticmethod def meets_condition(event_data: dict) -> bool: return False class UnknownEvent(Event): ... class LoginEvent(Event): @staticmethod def meets_condition(event_data: dict): return ( self.event_data["before"]["session"] == 0 and self.event_data["after"]["session"] == 1 ) class LogoutEvent(Event): @staticmethod def meets_condition(event_data: dict): return ( self.event_data["before"]["session"] == 1 and self.event_data["after"]["session"] == 0 ) class SystemMonitor(Event): def __init__(self, event_data): self.event_data = event_data def identify_event(self): for event_cls in Event.__subclasses__(): try: if event_cls.meets_condition(self.event_data): return event_cls(self.event_data) except KeyError: continue return UnkownEvent(self.event_data)
각각의 이벤트에 다형성을 가진 새로운 메서드를 추가해야 한다. 이 메서드는 전달되는 데이터가 해당 클래스의 타입과 일치하는지 판단하는 역할을 한다. 또한 기존 분류 로직을 수정하여 이 메서드를 사용해 전체 이벤트를 돌면서 검사해야 한다. identify_event()는 이제 특정 이벤트 타입과 비교하는 것이 아니고, 일반적인 인터페이스를 가진 제네릭 이벤트와 비교한다. 이 인터페이스를 따르는 제네릭들은 모두 meets_condition()을 구현하여 다형성을 보장한다.
 
subclasses() 메서드를 사용해 이벤트 유형을 찾는 것에 주목하자. 이제 새로운 유형의 이벤트를 지원하려면 Event 클래스를 상속받고 meet_condition()를 구현하면 된다. 그리고 identify_event()가 닫히게 된다. 새로운 유형의 이벤트가 도메인에 추가되더라도 수정할 필요가 없다. 반대로 이벤트 계층은 확장을 위해 열려 있다.
 

이벤트 시스템 확장

class TransactionEvent(Event): @staticmethod def meets_condition(event_data: dict): return event_data["after"].get("transaction") is not None
>>> 14 = SystemMonitor({"after": {"transaction": "Tx001"}}) >>> 14.identify_event().__class__.__name__ "TransactionEvent"
새 이벤트를 추가했지만 SystemMonitor.identify_event() 메서드는 전혀 수정하지 않았다. 따라서 이 메서드가 새로운 유형의 이벤트에 대해서 폐쇄되어 있다고 말할 수 있다.
반대로 Event 클래스는 필요할 때마다 추가할 수 있다. 이에 따라 이벤트는 새로운 타입의 확장에 개방되어 있다고 말할 수 있다.
 
 

최종 정리

개방-패쇄 원칙은 다형성의 효과적인 사용과 밀접하게 관련되어 있다. 이 원칙은 유지보수성에 대한 문제를 해결한다. 중요한 요점은 코드를 변경하지 않고 기능을 확장하기 위해서는 보호하려는 추상화에 대해서 적절한 패쇄를 해야 한다는 것이다.
 
 

리스코프 치환 원칙(Liskov’s subsitution)

설계의 안정성을 높이기 위해 객체가 가져야 하는 일련의 특성을 말한다.
클라이언트가 특별한 주의를 들이지 않아도 부모 클래스를 대신하여 하위 클래스를 그대로 사용할 수 있어야 한다. 클라이언트는 부모 타입 대신에 어떠한 하위 타입을 사용해도 정상적으로 작동해야 한다. S가 T의 하위 타입이라면 프로그램을 변경하지 않고 T의 객체를 S객체로 치환 가능해야 한다.
 
이 원칙은 계약을 통한 설계와도 관련이 있다. 주어진 타입과 클라이언트 사이에는 계약이 필요하다. LSP 규칙에 따르면 하위 클래스는 상위 클래스에서 정의한 계약을 따르도록 디자인해야 한다.
 

도구를 사용해 LSP 문제 검사하기

코드 전체에 타입 어노테이션을 사용하고, Mypy를 설정했다면 기본 오류 여부와 LSP 준수 여부를 빠르게 확인할 수 있다. Event 클래스의 하위 클래스 중 하나가 호환되지 않는 방식으로 메서드를 재정의하면 Mypy는 어노테이션을 검사하여 이를 확인한다.
class Event: ... def meets_condition(self, event_data: dict) -> bool: return False class LoginEvent(Event): def meets_condition(self, event_data: list) -> bool: return bool(evnet_data)
error: Argument 1 of "meets_condition" incompatible with supertype "Event"
이 코드는 명확히 LSP를 위반한다. 객체를 치환해도 애플리케이션 실행에 실패하면 안된다. 그렇지 않다면 계층 구조의 다형성이 손상된 것이다. 반환값이 bool이 아닌 다른 값으로 반환해도 오류가 발생한다.
파생 클래스가 부모 클래스에서 정의한 파라미터와 다른 타입을 사용. LSP원칙을 따랐다면 호출자는 아무런 차이를 느끼지 않고 Event 또는 LoginEvent를 사용할 수 있어야 한다.
 
즉 어떤 경우에는 이터러블 인터페이스를 통해 파라미터를 처리한다면 사전이나 리스트 중 아무것이나 받아도 상관이 없을 수 있다. 논리 자체에는 문제가 없지만, 실제 구현 내용에서 문제가 발생할 수도 있다. 이런 경우 메서드의 코드를 바꾸거나 전체 디자인을 바꾸거나 타입 어노테이션이라도 바꿔야 한다.
 
사전과 리스트. 이들은 이터러블 하다는 공통점이 있다. 따라서 클라이언트는 사전이나 리스트를 받기위해 이터러블 인터페이스를 파라미터 타입으로 정의할 수 있다. LSP의 원칙을 준수함으로 논리에는 문제가 없지만 실제 파라미터로 사전이나 리스트가 아닌 다른 이터러블 데이터 타입이 들어오면 문제가 발생한다. 이런 경우 메서드의 구조를 바꾸거나 전체 디자인을 다시 하거나 타입 어노테이션이라도 바꿔야 한다.
 
LSP는 객체 지향 설계 관점에서도 의미가 있다. 서브클래싱을 할 때는 구체화를 해야 하지만 각 서브클래스의 기본 틀은 부모 클래스가 선언하는 것이어야 한다.
 

pylint로 호환되지 않는 서명 검사

class LogoutEvnet(Event): def meets_condition(self, event_data: dict, override: bool) -> bool: if override: return True ....
Parameters differ from overridden "meets_condition" method (arguments-differ)
pylint, mypy와 같은 정적 코드 분석기를 사용해 초기에 이러한 오류를 잡을 수 있다.
 

애매한 LSP 위반 사례

어떤 경우에는 자동화된 도구로 검사하기 애매할 수 있다. 이런 경우는 코드 리뷰를 하면서 자세히 코드를 살펴볼 수밖에 없다. LSP에서 하위 클래스는 상위 클래스와 호환 가능하다는 점을 감안할 때 계약은 계층 구조 어디에서나 항상 유지되어야만 한다. DbC 에서 계약이 수정되는 경우는 특히 자동으로 감지하기가 더 어렵다. 부모 클래스는 클라이언트와의 계약을 정의한다. 하위 클래스는 그 계약을 따라야 한다. 예를 들면 아래와 같은 조건을 따라야 한다고 해보자.
  • 하위 클래스는 부모 클래스에 정의된 것보다 사전조건을 엄격하게 만들면 안된다.
  • 하위 클래스는 부모 클래스에 정의된 것보다 약한 사후조건을 만들면 안된다.
 
from collections.abc import Mapping class Event: def __init__(self, raw_data): self.raw_data = raw_data @staticmethod def meets_condition(event_data: dict) -> bool: return False @staticmethod def validate_precondition(evnet_data: dict): """인터페이스 계약의 사전조건 'event_data' 파라미터가 적절한 형태인지 유효성 검사 """ if not isinstance(event_data, Mapping): raise ValueError(f"{event_data!r} dict 데이터 타입이 아님") for moment in ("before", "after"): if moment not in event_data: raise ValueError(f"{event_data}에 {moment} 정보가 없음") if not isinstance(event_data[moment], Mapping): raise ValueError(f"event_data[moment!r] dict 데이터 타입이 아님!")
이번 예제는 사전조건에서 파라미터가 사전 타입인지, 그리고 "before", "after" 키를 가지고 있는지 확인한다. "before", "after" 키의 값은 또다시 객체를 내포해야 한다. 이렇게 하면 클라이언트는 KeyError를 받지 않으므로 보다 발전된 캡슐화를 할 수 있다. 사전조건 체크 메서드만 호출하면 되기 때문이다.
사전조건 검증에 실패한 경우 시스템 실패로 처리해도 무방하다고 가정한다. 이제 SystemMonitor 는 더 이상 협력하는 클래스에서 어떤 예외를 발생시키는지 몰라도 상관없다.
 
class SystemMonitor: """시스템에서 발생한 이벤트 분류""" def __init__(self, event_data): self.event_data = event_data def identify_event(self): Event.meets_condition_pre(self.event_data) event_cls = next( ( event_cls for event_cls in Event.__subclasses__() if event_cls.meets_condition(self.event_data) ), UnknownEvent, ) return event_cls(self.event_data)
이제 올바른 이벤트 유형을 확인하기 전에 사전조건을 먼저 검사한다.
계약은 오직 최상위 레벨의 키 "before", "after" 가 필수이고, 그 값 또한 사전 타입이어야 한다고만 명시되어 있다. 하위 클래스에서 보다 제한적인 파라미터를 요구하는 경우 검사에 통과하지 못한다.
앞서 만든 트랜잭션 이벤트 클래스는 올바르게 설계되었다. "transaction" 이라는 키에 제한을 두지 않고 사용하고 있다. 그 값이 있을 경우에만 사용하고 필수로 꼭 필요한 것은 아니다.
 
 

최종 정리

LSP 는 객체지향 소프트웨어 설계의 핵심이 되는 다형성을 강조하기 때문에 좋은 디자인의 기초가 된다. 인터페이스의 메서드가 올바른 계층구조를 갖도록 하여 상속된 클래스가 부모 클래스와 다형성을 유지하도록 하는 것이다.
이는 앞선 OCP 와도 관련되어 있다. 새로운 클래스가 원래의 계약과 호환되지 않는 확장을 하려고 하면 클라이언트와의 계약이 깨져서 결과적으로 그러한 확장이 불가능하다. 또는 확장을 가능하게 하려면 수정에 대해 폐쇄되어야 한다는 원칙을 깨야 한다.
LSP 에서 제안하는 방식으로 신중하게 클래스를 디자인하면 계층을 올바르게 확장하는데 도움이 된다. 즉 LSP 가 OCP에 기여한다고 볼 수 있다.
 
 

 

인터페이스 분리 원칙 (Interface sergregation)

인터페이스는 객체가 노출하는 메서드의 집합이다. 여러 메서드를 가진 인터페이스가 있다면 구분에 따라 더 적은 수의 메서드를 가진 인터페이스로 분할하는 것이 좋다. 이는 클래스가 명확한 동작과 책임을 지니기에 응집력이 높아진다.
SRP과 유사하게 느껴졌는데 생각해보면 SOLID원칙 모두 상호 연관성이 있고, ISP와의 차이점은 더 직접적으로 인터페이스에 대해 이야기하고 있다는 점에서 다르다.
 
from abc import * class EventParser(metaclass=ABCMeta): ... @abstractmethod def from_xml(self, event_data): pass @abstractmethod def from_json(self, event_data): pass
  • from_json()만을 필요로 하는 클라이언트에게 from_xml()까지 제공한다.
  • 유연성을 떨어트리며, 필요하지 않은 메서드도 구현하게끔 강제한다.
 
from abc import ABCMeta, abstractmethod class XMLEventParser(metaclass=ABCMeta): @abstractmethod def from_xml(xml_data: str): """XML 형태의 데이터를 파싱""" class JSONEventParser(metaclass=ABCMeta): @abstractmethod def from_json(xml_data: str): """JSON 형태의 데이터를 파싱""" class EventParser(XMLEventParser, JSONEventParser): """XML 과 JSON 형태의 데이터를 파싱""" def from_xml(xml_data): pass def from_json(json_data): pass
### 추상메서드를 구현하지 않으면 런타임 에러 >>> from src.isp import EventParser >>> EventParser() Traceback(most recent call last): File "<stdin>", line 1, in <module> TypeError: Can't instaniate abstract class EventParser with absteract methods from_json, from_xml
  • 인터페이스를 분리함으로서 필요로 하지 않는 메서드에 의존하게 되는 문제를 해결했다.
  • 유연하게 조합할 수 있다.
 

 

의존성 역전

고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다. 이것을 아주 쉽게 말하면, "자신보다 변하기 쉬운 것에 의존하지 마라"이다.
 
### DIP를 적용하지 않은 사례 class EventStreamer(): def __init__(self): self._target = Syslog() def stream(self, events: list[Evnet]) -> None: for event in events: self._target.send(event.serialise())
  • 고수준의 모듈이 저수준의 모듈을 참조함으로써, 저수준 모듈에 의존하고 있다.
  • Syslog의 send() 함수가 변경되었을 때에는 EventStreamer 클래스도 변경해야 할 가능성이 생긴다.
  • Syslog에 의존해 있으므로 단위테스트에서 문제가 생길 수 있다.
 
### DIP 적용 사례 class EventStreamer(): def __init__(self, target: DataTargetClient): self._target = target def stream(self, events: list[Evnet]) -> None: for event in events: self._target.send(event.serialise())
  • EventStreamer 클래스는 단지 DataTargetClient 인터페이스와 관계를 가질뿐, 의존성이 사라지게 된다.
  • 단위테스트에서 테스트 더블을 사용해 제공하기만 하면 된다.

댓글

guest